复习资料核心导航
核心考点串讲:
- 游戏Demo设定初衷 (贪吃蛇, 俄罗斯方块)
- Console平台知识回顾
- 游戏中的概率设置 (重点: Bag7规则)
- SFML核心知识 (交互, 纹理, 渲染)
- 【重点】Tetris功能函数细节拆解
static
关键字的用法- 鼠标交互机制
- 碰撞检测机制 (AABB与穿模)
- 游戏舞台绘制原理
- 【重点】精灵表单与精灵动画
- 快速构建游戏的方法
考点一:了解教材游戏 Demo 的设定初衷
这部分是理解游戏设计递进关系的入门。
- 贪吃蛇 (Snake):
- 目的: 教学最基础的游戏循环、网格(Grid)概念、键盘输入、简单碰撞检测(撞墙、撞自己)和游戏状态管理(开始、游戏中、结束)。它是从静态程序到动态实时程序的“桥梁”。
- 俄罗斯方块 (Tetris):
- 目的: 引入更复杂的游戏逻辑。它在贪吃蛇的基础上,增加了物体旋转、更复杂的碰撞逻辑(与已固定的方块)、行消除与数据移动、游戏节奏控制(方块下落速度随等级提升)、以及更优的随机性(Bag7规则)。它极好地锻炼了数据结构(二维数组)与算法(碰撞、旋转、消行)的结合能力。
- 扫雷 (Minesweeper): (虽然不考,但理解其目的有帮助)
- 目的: 核心是鼠标交互、状态管理(每个格子的 未打开/已打开/已插旗 状态)以及递归算法(点开一个空白格时,自动展开周围的空白区域)。
考点二:了解 Console 平台的交互、图形绘制
这是你从C语言到游戏开发的起点,理解其原理有助于理解SFML为何是巨大进步。
- 图形绘制:
- 原理: Console本质是字符界面,没有像素。所谓的“图形”是用字符(如
*
,#
,■
)模拟的。 - 实现: 通过控制台API(如Windows的
gotoxy()
)或ANSI转义序列,将光标移动到特定坐标(x, y)
,然后用printf
或cout
输出一个字符。 - 双缓冲: 这是为了解决画面“闪烁”问题的核心技术。
- 在内存中创建一个二维字符数组
char screenBuffer[HEIGHT][WIDTH]
。 - 所有“绘制”操作都先更新这个内存中的数组。
- 当一帧的所有内容都“画”好后,一次性地将整个
screenBuffer
打印到屏幕上(通常会配合清屏操作)。这样人眼就看不到逐个字符绘制的过程,画面变得流畅。
- 在内存中创建一个二维字符数组
- 原理: Console本质是字符界面,没有像素。所谓的“图形”是用字符(如
- 交互:
- 原理: 标准的
cin
或scanf
是阻塞式的,会等待用户回车,不适用于实时游戏。 - 实现: 使用非阻塞的输入函数,如
<conio.h>
中的_kbhit()
(检查是否有按键) 和_getch()
(直接获取按键,不回显)。 - 游戏循环中的应用: 在每一帧循环中,调用
_kbhit()
检查输入,如果有,则用_getch()
读取并处理。
- 原理: 标准的
考点三:了解游戏中的概率设置
-
触发概率 (Trigger Probability):
- 概念: 指某个事件发生的可能性。
- 实现: 通常用
rand()
函数。例如,实现一个10%的概率触发事件:if ((rand() % 100) < 10) { // 触发事件 (100个数字中取到了0-9,共10个) }
-
组合概率与伪随机 (重点: Tetris 的 Bag7 规则):
- 问题: 纯随机 (
rand() % 7
) 可能导致玩家连续得到不想要的方块(如多个S和Z),或者长时间得不到关键的I长条。这会让游戏体验很差。 - Bag7 规则 (七子一包): 是一种伪随机分布算法,能保证方块的“公平”出现。
- 实现:
- 创建一个包含所有7种方块的“袋子”(如
std::vector<int> bag = {0, 1, 2, 3, 4, 5, 6};
)。 - 洗牌: 使用
std::random_shuffle
(旧) 或std::shuffle
(C++11及以后) 将袋子里的方块顺序打乱。 - 抽取: 每次需要新方块时,从袋子末尾取出一个并删除。
- 补充: 当袋子空了,重新将7种方块装入,再次洗牌。
- 创建一个包含所有7种方块的“袋子”(如
#include <vector> #include <algorithm> // for std::shuffle #include <random> // for std::mt19937 std::vector<int> pieceBag; std::mt19937 rng(std::random_device{}()); // 高质量随机数生成器 void refillBag() { pieceBag = {0, 1, 2, 3, 4, 5, 6}; // T, J, Z, O, S, L, I std::shuffle(pieceBag.begin(), pieceBag.end(), rng); } int getNextPiece() { if (pieceBag.empty()) { refillBag(); } int pieceType = pieceBag.back(); pieceBag.pop_back(); return pieceType; }
- 问题: 纯随机 (
考点四:SFML 的基本知识
这是从Console迈向现代2D游戏开发的核心。
- 背景与概念: SFML是一个简单快速的多媒体库,不是游戏引擎。它提供2D图形、音频、网络、窗口和输入的底层API,让我们能专注于游戏逻辑本身。
- 交互 (Input):
- 事件驱动: 通过
while(window.pollEvent(event))
循环处理。适合一次性动作,如按键按下(sf::Event::KeyPressed
)、鼠标点击(sf::Event::MouseButtonPressed
)。 - 实时查询: 在主循环中直接查询
sf::Keyboard::isKeyPressed(sf::Keyboard::Key)
。适合持续性动作,如按住方向键移动。
- 事件驱动: 通过
- 纹理 (Texture) 与精灵 (Sprite):
sf::Texture
: 存储在显存中的图像资源。加载一次,可多次使用。生命周期必须覆盖所有使用它的Sprite。sf::Sprite
: 一个可绘制实体,它引用一个sf::Texture
来显示。可以对Sprite进行移动、旋转、缩放,而不会影响Texture本身。
- 字体 (Font) 与文本 (Text):
- 与Texture/Sprite关系类似。
sf::Font
是字体资源,sf::Text
是使用该字体显示的可绘制实体。
- 与Texture/Sprite关系类似。
- 渲染 (Rendering):
- 核心流程 (The Game Loop):
while (window.isOpen()) { // 1. 处理事件 // 2. 更新逻辑 (移动、碰撞、计分等) // 3. 渲染 window.clear(); // 清屏 window.draw(object); // 绘制所有对象 window.display(); // 显示到屏幕 }
window.clear()
: 用背景色填充后台缓冲区。window.draw()
: 将对象绘制到后台缓冲区。window.display()
: 将后台缓冲区的内容与前台缓冲区交换,实现双缓冲,防止闪烁。
- 核心流程 (The Game Loop):
考点五:【重点】Tetris 的各功能函数细节
这是考试的核心,要求你对俄罗斯方块的实现有深入理解。
1. 核心数据结构
const int M = 20; // 舞台高度
const int N = 10; // 舞台宽度
// 游戏舞台 (Playfield),用二维数组表示。0表示空,非0表示方块颜色/类型
int field[M][N] = {0};
// 方块的形状定义 (4x4的二维数组)
// 7种方块 x 4种旋转状态
int figures[7][4][4][4] = { /* ... 大量0和1定义 ... */ };
// 当前下落的方块
struct Point { int x, y; };
Point currentPiecePos; // 当前方块在舞台上的坐标
int currentPieceType; // 当前是哪种方块 (0-6)
int currentRotation; // 当前旋转状态 (0-3)
2. 关键功能函数拆解
-
bool checkCollision()
- 碰撞检测函数 (核心中的核心)- 目的: 判断当前方块在其当前位置是否与边界或已固定的方块发生重叠。
- 逻辑:
- 遍历当前方块的4x4定义矩阵。
- 对于矩阵中为
1
的每一个小方块: a. 计算它在field
舞台上的绝对坐标:int blockX = currentPiecePos.x + col;
int blockY = currentPiecePos.y + row;
b. 进行检查:- 是否超出左右边界? (
blockX < 0 || blockX >= N
)
- 是否超出下边界? (
blockY >= M
) - 是否与
field
中已有的方块重叠? (field[blockY][blockX] != 0
) c. 只要任意一个小方块满足上述任一条件,就立即返回true
(表示碰撞)。
- 是否超出左右边界? (
- 返回值:
true
表示碰撞,false
表示安全。 - 应用场景:
- 移动前:
currentPiecePos.x++
后,调用checkCollision()
。如果为true
,则撤销移动currentPiecePos.x--
。 - 旋转前:
currentRotation++
后,调用checkCollision()
。如果为true
,则撤销旋转currentRotation--
。(高级玩法中这里会尝试“墙踢 Wall Kick”) - 判断落地: 当方块因重力下落一格后
checkCollision()
为true
,说明它已经碰到底部或其他方块,需要被固定。
- 移动前:
-
void placePiece()
- 固定方块函数- 目的: 当
checkCollision()
确认方块已落地,将其“印”在field
数组上。 - 逻辑:
- 再次遍历当前方块的4x4矩阵。
- 对于矩阵中为
1
的每一个小方块: a. 计算其在field
上的绝对坐标。 b. 将field
对应位置的值设为该方块的颜色/类型:field[blockY][blockX] = currentPieceType + 1;
(加1是为了避免与0
混淆)。
- 目的: 当
-
void clearLines()
- 消行函数- 目的: 在固定一个新方块后,检查并消除所有满行。
- 逻辑:
- 从
field
的最底行 (M-1
) 向上遍历到最顶行 (0
)。 - 对每一行,检查是否已满(即该行所有
N
个格子都不为0
)。 - 如果第
i
行满了: a. 记录消行数(用于计分)。 b. 将i
行之上的所有行整体向下移动一行。- 一个高效的实现是从
i
行开始向上for (int k = i; k > 0; k--)
,将k-1
行的数据复制到k
行:field[k] = field[k-1]
。 - 最顶行 (
field[0]
) 则清空为全0
。 c. 注意: 因为下移了一行,当前行i
需要重新检查一次,所以循环变量可以不变,或者使用while
循环。
- 一个高效的实现是从
- 从
-
void rotatePiece()
- 旋转函数- 目的: 改变
currentRotation
并检查合法性。 - 逻辑:
- 保存原始旋转状态
int oldRotation = currentRotation;
- 更新旋转状态
currentRotation = (currentRotation + 1) % 4;
- 调用
checkCollision()
。 - 如果碰撞了 (
checkCollision()
返回true
),则恢复原始旋转currentRotation = oldRotation;
。
- 保存原始旋转状态
- 目的: 改变
考点六:掌握 Static 的用法
static
关键字用于改变变量的存储周期和链接属性,或限定函数的作用域。
-
static
局部变量:- 位置: 函数内部。
- 特点: 只在程序第一次执行到该声明时初始化一次。其生命周期是整个程序的运行时间,而不是函数调用期间。
- 用途: 在函数调用之间保持状态。例如,一个只执行一次的初始化代码块。
void someFunction() { static bool isInitialized = false; if (!isInitialized) { // 只会运行一次的初始化代码 isInitialized = true; } }
-
static
全局变量/函数:- 位置: 全局作用域(任何函数之外)。
- 特点: 将变量或函数的链接属性从
external
(外部) 变为internal
(内部)。这意味着该变量或函数只在其所在的源文件 (.cpp) 内可见,其他文件无法通过extern
访问。 - 用途: 避免多个文件间的命名冲突,实现数据或功能的封装。
-
static
类成员变量:- 特点: 该变量属于类本身,而不是类的某个特定对象。所有该类的对象共享同一个
static
成员变量。必须在类外进行初始化。 - 用途:
- 统计创建了多少个对象。
- 管理共享资源,如一个
ResourceManager
类中的static std::map<std::string, sf::Texture> textures;
,确保纹理只加载一次。
class Player { public: static int playerCount; Player() { playerCount++; } }; int Player::playerCount = 0; // 在类外初始化
- 特点: 该变量属于类本身,而不是类的某个特定对象。所有该类的对象共享同一个
-
static
类成员函数:- 特点: 该函数也属于类本身,不与任何对象关联,因此它没有
this
指针,不能访问非static
成员变量。 - 用途: 实现不需要对象实例就能调用的工具函数。比如
Math::clamp(value, min, max)
。class Config { public: static int getTileSize() { return 32; } }; // 调用方式: int size = Config::getTileSize();
- 特点: 该函数也属于类本身,不与任何对象关联,因此它没有
考点七:掌握鼠标交互的机制
-
事件处理:
- 在
pollEvent
循环中捕获鼠标事件:sf::Event::MouseButtonPressed
: 鼠标按键被按下。sf::Event::MouseButtonReleased
: 鼠标按键被释放。sf::Event::MouseMoved
: 鼠标移动。
- 在
-
获取信息:
event.mouseButton.button
: 判断是哪个键(sf::Mouse::Left
,sf::Mouse::Right
)。event.mouseButton.x
,event.mouseButton.y
: 获取点击事件发生时的窗口坐标。sf::Mouse::getPosition(window)
: 实时获取鼠标相对于窗口的当前坐标。
-
坐标转换 (重要):
- 如果你的游戏使用了
sf::View
(摄像机),那么鼠标的窗口坐标不等于游戏世界坐标。 - 必须使用
window.mapPixelToCoords(mousePosition)
将窗口像素坐标转换为游戏世界坐标。
- 如果你的游戏使用了
-
点击检测:
- 最常用的方法是检测鼠标坐标是否在某个对象的包围盒 (Bounding Box) 内。
sf::Sprite
,sf::Text
,sf::Shape
都有getGlobalBounds()
方法,返回一个sf::FloatRect
。sf::FloatRect
有一个contains(x, y)
方法。
if (event.type == sf::Event::MouseButtonPressed) { if (event.mouseButton.button == sf::Mouse::Left) { // 获取鼠标在窗口中的像素位置 sf::Vector2i pixelPos = sf::Mouse::getPosition(window); // 转换为游戏世界坐标 sf::Vector2f worldPos = window.mapPixelToCoords(pixelPos); // 检查是否点中了某个精灵 if (mySprite.getGlobalBounds().contains(worldPos)) { // 点击成功! } } }
考点八:掌握碰撞检测机制的底层逻辑(穿模)
-
AABB (Axis-Aligned Bounding Box) 轴对齐包围盒:
- 这是2D游戏中最简单、最常用的碰撞检测算法。一个矩形的
(x, y, width, height)
定义了它的范围。 - SFML实现:
sprite.getGlobalBounds()
返回的sf::FloatRect
就是一个AABB。rect1.intersects(rect2)
直接完成了检测。 - 底层逻辑: 两个AABB(
r1
,r2
)相交的条件是:r1.x < r2.x + r2.width
(r1的左边 在 r2的右边 的左侧)r1.x + r1.width > r2.x
(r1的右边 在 r2的左边 的右侧)r1.y < r2.y + r2.height
(r1的上边 在 r2的下边 的上侧)r1.y + r1.height > r2.y
(r1的下边 在 r2的上边 的下侧)- 这四个条件必须同时满足。
- 这是2D游戏中最简单、最常用的碰撞检测算法。一个矩形的
-
穿模 (Tunneling):
- 现象: 当一个物体在一帧内的移动速度过快,其位移大于另一个物体的厚度时,它可能在上一帧还在物体前,下一帧就“跳”到了物体后,导致
intersects
检测在两帧都为false
,从而“穿过”了障碍物。 - 原因: 游戏是离散的,我们只在每一帧的特定时间点检查位置,而不是连续检查。
- 解决方案 (从易到难):
- 限制速度: 最简单的办法,确保物体的最大位移小于潜在碰撞物的最小尺寸。
- 子步进 (Sub-stepping): 在一帧内,将物体的移动分成几个小步骤。每移动一小步就做一次碰撞检测。这是性能和效果的良好折衷。
- 连续碰撞检测 (CCD): 预测物体在下一帧的运动轨迹(通常是条线段),检查这个轨迹是否会与其他物体相交。这是最精确但最复杂的方法。
- 现象: 当一个物体在一帧内的移动速度过快,其位移大于另一个物体的厚度时,它可能在上一帧还在物体前,下一帧就“跳”到了物体后,导致
考点九:掌握游戏舞台绘制的原理
-
绘制顺序 (Z-ordering):
- SFML的绘制遵循“画家算法”,后画的会覆盖先画的。
- 正确的绘制顺序至关重要:
window.draw(backgroundSprite);
// 先画背景window.draw(environmentObjects);
// 再画场景物体window.draw(playerSprite);
// 然后画玩家和敌人window.draw(foregroundEffects);
// 再画前景(如粒子效果)window.draw(uiText);
// 最后画UI界面
-
位置摆放与偏移 (Coordinate Systems):
- 游戏逻辑坐标 (Grid/World Coordinates): 这是你在游戏逻辑中使用的坐标。例如,Tetris的
field[y][x]
,这里的(x, y)
就是逻辑坐标。 - 屏幕渲染坐标 (Screen Coordinates): 这是物体在窗口上显示的实际像素坐标。
- 转换: 绘制时,需要将逻辑坐标转换为屏幕坐标。
- 公式:
screenX = stageOffsetX + logicX * tileSize;
screenY = stageOffsetY + logicY * tileSize;
- 公式:
stageOffsetX/Y
: 游戏舞台(如Tetris的field
)在整个窗口中的左上角偏移量。tileSize
: 每个逻辑单元(格子)对应的像素大小。
Tetris绘制示例:
int tileSize = 32; int stageOffsetX = 100; int stageOffsetY = 50; // 绘制舞台上已固定的方块 for (int y = 0; y < M; y++) { for (int x = 0; x < N; x++) { if (field[y][x] != 0) { sf::RectangleShape block(sf::Vector2f(tileSize, tileSize)); block.setFillColor(getColorForType(field[y][x])); // 关键的坐标转换 block.setPosition(stageOffsetX + x * tileSize, stageOffsetY + y * tileSize); window.draw(block); } } }
- 游戏逻辑坐标 (Grid/World Coordinates): 这是你在游戏逻辑中使用的坐标。例如,Tetris的
考点十:【重点】精灵表单 (Sprite Sheet) 与 精灵动画 (Sprite Animation)
这个考点是图形效率和表现力的关键。
1. 精灵表单 (Sprite Sheet / Texture Atlas)
-
是什么: 一个包含多个独立图像(如图块、动画帧、UI元素)的单张大图。
-
为什么用:
- 性能: GPU切换当前使用的纹理是一项耗时操作。将许多小图合并成一张大图,可以极大减少GPU的纹理切换次数(也叫 Draw Call 的合并),从而显著提升渲染性能。
- 管理: 管理一个大文件比管理成百上千个小文件要容易得多。
-
如何用:
- 加载整张大图到一个
sf::Texture
。sf::Texture spriteSheet; spriteSheet.loadFromFile("character_walk.png");
- 创建一个
sf::Sprite
,并设置其纹理为这张大图。sf::Sprite playerSprite; playerSprite.setTexture(spriteSheet);
- 使用
sprite.setTextureRect(sf: :IntRect(left, top, width, height))
来指定要显示大图的哪个子区域。sf::IntRect
的四个参数定义了一个矩形。
示例: 假设一张
128x32
的图上有4帧32x32
的动画。- 显示第1帧:
playerSprite.setTextureRect(sf: :IntRect(0, 0, 32, 32));
- 显示第2帧:
playerSprite.setTextureRect(sf: :IntRect(32, 0, 32, 32));
- 显示第3帧:
playerSprite.setTextureRect(sf: :IntRect(64, 0, 32, 32));
- 加载整张大图到一个
2. 精灵动画 (Sprite Animation)
- 是什么: 通过快速、连续地切换精灵显示的
TextureRect
,给人眼造成物体在动的错觉。 - 如何实现:
-
数据准备:
- 定义动画的所有帧在精灵表单上的位置(一系列的
sf::IntRect
)。 - 一个变量记录当前播放到第几帧
int currentFrame
。 - 一个计时器
sf::Clock
来控制播放速度。 - 一个变量定义每帧的持续时间
float frameDuration
。
- 定义动画的所有帧在精灵表单上的位置(一系列的
-
在游戏循环中更新:
class Animation { public: std::vector<sf::IntRect> frames; float frameDuration = 0.1f; // 每帧持续0.1秒 int currentFrame = 0; sf::Clock clock; void update() { if (clock.getElapsedTime().asSeconds() > frameDuration) { // 时间到了,切换到下一帧 currentFrame = (currentFrame + 1) % frames.size(); // 循环播放 clock.restart(); } } sf::IntRect getCurrentFrameRect() { return frames[currentFrame]; } }; // 在你的游戏类中... Animation playerWalkAnim; // ... 在初始化时,填充 playerWalkAnim.frames ... // playerWalkAnim.frames.push_back(sf::IntRect(0, 0, 32, 32)); // playerWalkAnim.frames.push_back(sf::IntRect(32, 0, 32, 32)); // ... // 在主循环的更新部分 playerWalkAnim.update(); // 在主循环的渲染部分 playerSprite.setTextureRect(playerWalkAnim.getCurrentFrameRect()); window.draw(playerSprite);
-
考点十一:掌握快速构建游戏的方法
这部分是经验和编程思想的总结。
-
迭代开发 (Iterative Development):
- 不要试图一次性写完所有功能。
- 从最小可行产品 (MVP) 开始:
- 先让一个窗口能打开和关闭。
- 在窗口里画一个方块。
- 让方块能响应键盘输入移动。
- 添加另一个方块,实现碰撞检测。
- …逐步增加功能。
-
代码模块化 (Modular Code):
- 封装: 将相关的数据和功能封装到类中。例如,创建一个
Player
类,一个Enemy
类,一个GameStateManager
类。这使得代码更易于管理和复用。 - 分离: 将游戏逻辑和渲染代码分开。一个对象的
update()
方法负责处理其逻辑(移动、AI),而draw()
方法只负责将其绘制到屏幕上。
- 封装: 将相关的数据和功能封装到类中。例如,创建一个
-
使用占位符 (Placeholders):
- 在没有最终美术资源时,不要干等。使用
sf::RectangleShape
,sf::CircleShape
等基本形状作为临时替代品(“placeholder art”)。这能让你专注于核心玩法的开发。
- 在没有最终美术资源时,不要干等。使用
-
资源管理 (Resource Management):
- 不要在主循环中加载资源!
loadFromFile
是耗时操作。 - 创建一个
ResourceManager
类(通常使用static
成员),在游戏开始时一次性加载所有需要的纹理、字体和声音。游戏过程中,任何需要资源的地方都向这个管理器请求,而不是自己去加载。
- 不要在主循环中加载资源!
补充
俄罗斯方块中的踢墙机制
好的,我们来详细讲解俄罗斯方块中的踢墙 (Wall Kick) 机制。这是一个从经典版到现代版俄罗斯方块的重要演进,极大地改善了游戏的手感和流畅度。
1. 为什么需要踢墙?
想象一个经典(没有踢墙)的俄罗斯方块。当一个方块(比如 T
形块)紧贴着墙壁或已经固定的方块时,如果你尝试旋转它,会发生什么?
- 执行旋转: 方块的形状改变,某些小方格会移动到新的相对位置。
- 碰撞检测: 新的位置很可能会与墙壁或旁边的方块重叠。
- 旋转失败: 由于发生了碰撞,系统会判定这次旋转无效,方块会立刻恢复到旋转前的状态。
结果就是: 玩家会感觉“卡住了”,明明看起来有空间,但方块就是不让你转。这非常影响游戏体验,尤其是在高速下落时,一次失败的旋转可能直接导致游戏结束。
踢墙机制就是为了解决这个问题而生的。
2. 踢墙的核心思想
踢墙的核心思想是:当一次常规旋转因为碰撞而失败时,不要立刻放弃。尝试将方块向左或向右平移一到两个格子,再次检查是否能成功旋转。如果平移后能找到一个不碰撞的位置,那么就将方块“踢”到那个位置并完成旋转。
这就像你在一个狭窄的走廊里搬一个沙发,直接转不过去,但你把沙发往旁边挪一点点,可能就正好能转过来了。这个“挪一点点”的动作,就是“踢墙”。
3. SRS (Super Rotation System) - 现代俄罗斯方块的标准
目前绝大多数官方和主流的俄罗斯方块游戏都遵循一个名为 SRS (Super Rotation System) 的标准。SRS 精确地定义了每种方块在每次旋转时应该尝试哪些“踢墙”位置,以及尝试的顺序。
3.1 关键要素:旋转中心 (Pivot)
首先,要理解 SRS,必须知道每个方块的旋转中心点(Pivot)。除了 O
形块(不旋转)和 I
形块(旋转中心比较特殊)外,其他方块(T
, J
, L
, S
, Z
)的旋转中心通常都在其 3x3 的包围盒内。
当执行旋转时,是围绕这个中心点来移动其他小方格的。
3.2 踢墙测试表 (Wall Kick Tables)
SRS 的核心是一系列偏移测试表 (Offset Test Tables)。当一次旋转失败时,系统会按照预定义的顺序,尝试将方块应用这些偏移量。
- 偏移量通常表示为
(Δx, Δy)
。 - 不同的方块 (
I
和O
vs 其他) 和不同的旋转方向 (顺时针 vs 逆时针) 会使用不同的测试表。
我们来看一个最常见的 J, L, S, T, Z
方块的踢墙数据表。表格中的数字代表从一个旋转状态到另一个旋转状态时,需要进行的5次测试(包括一次不偏移的初始测试)。
旋转状态定义:
0
: 初始状态R
: 顺时针旋转90度 (Right)L
: 逆时针旋转90度 (Left)2
: 旋转180度
旋转 | 测试 1 (初始) | 测试 2 | 测试 3 | 测试 4 | 测试 5 |
---|---|---|---|---|---|
0 -> R | ( 0, 0) | (-1, 0) | (-1, +1) | ( 0, -2) | (-1, -2) |
R -> 0 | ( 0, 0) | (+1, 0) | (+1, -1) | ( 0, +2) | (+1, +2) |
R -> 2 | ( 0, 0) | (+1, 0) | (+1, -1) | ( 0, +2) | (+1, +2) |
2 -> R | ( 0, 0) | (-1, 0) | (-1, +1) | ( 0, -2) | (-1, -2) |
2 -> L | ( 0, 0) | (+1, 0) | (+1, +1) | ( 0, -2) | (+1, -2) |
L -> 2 | ( 0, 0) | (-1, 0) | (-1, -1) | ( 0, +2) | (-1, +2) |
L -> 0 | ( 0, 0) | (-1, 0) | (-1, -1) | ( 0, +2) | (-1, +2) |
0 -> L | ( 0, 0) | (+1, 0) | (+1, +1) | ( 0, -2) | (+1, -2) |
注意:
- 坐标系通常是
(x, y)
,其中x
向右为正,y
向下为正。但有些实现中y
向上为正,这会影响+
和-
号。上表假设y
轴向下为正。 I
形块有自己的一套更大幅度的踢墙表,因为它更长。O
形块没有踢墙数据,因为它不旋转。
4. 踢墙的实现逻辑
现在,我们把这个机制整合到你的 rotatePiece()
函数中。
旧的 rotatePiece()
(无踢墙):
void rotatePiece() {
int oldRotation = currentRotation;
currentRotation = (currentRotation + 1) % 4; // 尝试旋转
if (checkCollision()) { // 如果碰撞
currentRotation = oldRotation; // 撤销旋转
}
}
新的 rotatePiece()
(带踢墙):
// 假设你已经定义好了上面的踢墙数据表
// srs_kick_data[piece_type][rotation_transition][test_index] -> {dx, dy}
void rotatePiece(bool clockwise) {
int oldRotation = currentRotation;
int newRotation;
if (clockwise) {
newRotation = (currentRotation + 1) % 4;
} else {
newRotation = (currentRotation + 3) % 4; // +3 等价于 -1
}
// 1. 获取本次旋转需要使用的测试数据表
// (这需要一个函数或查找表来确定,比如从 0->R, R->2 等)
const auto& kickTests = getKickDataForTransition(currentPieceType, oldRotation, newRotation);
// 2. 依次尝试每个踢墙测试
for (int i = 0; i < 5; ++i) {
// 获取第 i 个测试的偏移量
Point offset = kickTests[i];
// 3. 临时应用偏移量
Point originalPos = currentPiecePos;
currentPiecePos.x += offset.x;
currentPiecePos.y += offset.y;
// 4. 在新位置、新旋转状态下进行碰撞检测
currentRotation = newRotation; // 临时应用旋转
bool success = !checkCollision(); // 检查是否成功 (不碰撞)
// 恢复旋转和位置,以便下一次循环或最终确认
currentRotation = oldRotation;
currentPiecePos = originalPos;
// 5. 如果测试成功
if (success) {
// 正式应用旋转和踢墙后的位置
currentRotation = newRotation;
currentPiecePos.x += offset.x;
currentPiecePos.y += offset.y;
return; // 成功,函数结束
}
}
// 如果所有5次测试都失败了,说明真的转不了,什么也不做。
}
代码逻辑分解:
- 确定目标状态: 计算出旋转后的
newRotation
。 - 获取踢墙数据: 根据方块类型、原始旋转状态和目标旋转状态,从预定义的数据表中找出对应的5组偏移测试
(Δx, Δy)
。 - 循环测试:
- 测试0 (
(0, 0)
): 这是常规旋转,不进行任何平移。如果这次就成功了,函数直接返回,手感和经典版一样。 - 测试1-4: 如果测试0失败,就进入真正的“踢墙”环节。
- 在每次测试中,临时将方块移动到
当前位置 + 偏移量
,并临时改变其旋转状态。 - 调用
checkCollision()
。 - 如果不碰撞 (
checkCollision()
返回false
),说明找到了一个可行的位置!- 正式将方块的位置和旋转更新到这个成功的状态。
- 立即
return
,不再尝试后续的测试。
- 测试0 (
- 全部失败: 如果循环结束,所有5次测试都失败了,那么这次旋转就是无效的,函数结束,方块保持原状。
5. T-Spin (T旋) - 踢墙机制的妙用
踢墙机制不仅提升了基本的游戏手感,还催生了一种高级技巧——T-Spin。
- 定义: T-Spin 指的是利用踢墙,将
T
形块旋转塞入一个看似无法直接进入的 “T” 形凹槽中。 - 价值: 在现代俄罗斯方块规则中,完成 T-Spin 并消行会获得极高的分数奖励,甚至比消除四行 (Tetris) 的奖励还要高。这鼓励玩家去创造和利用这些复杂的形状。
上图中,T
形块无法直接下落到那个空位里。但玩家可以把它移动到空位的正上方,然后执行一次旋转。由于直接旋转会和上方的方块碰撞,踢墙机制启动,将 T
块向下“踢”了一格,正好嵌入凹槽。